diff --git a/CI.json b/CI.json index 11ffc5dc..7a0ea46d 100644 --- a/CI.json +++ b/CI.json @@ -15,6 +15,10 @@ "name": "g14-artifact-publish-static-check", "run": "node --check scripts/artifact-publish.mjs && node --check scripts/g14-artifact-publish.mjs" }, + { + "name": "codex-api-forwarder-check", + "run": "node --check cmd/hwlab-codex-api-responses-forwarder/main.mjs && node --check cmd/hwlab-codex-api-responses-forwarder/main.test.mjs && node --test cmd/hwlab-codex-api-responses-forwarder/main.test.mjs" + }, { "name": "parse-deploy-json", "run": "node -e \"JSON.parse(require('node:fs').readFileSync('deploy/deploy.json','utf8'))\"" diff --git a/cmd/hwlab-codex-api-responses-forwarder/main.mjs b/cmd/hwlab-codex-api-responses-forwarder/main.mjs new file mode 100644 index 00000000..1d184934 --- /dev/null +++ b/cmd/hwlab-codex-api-responses-forwarder/main.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node +import { createServer, request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; + +const serviceId = "hwlab-codex-api-responses-forwarder"; +const defaultPort = 49280; +const port = parsePositiveInteger(process.env.HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT || process.env.PORT, defaultPort); +const host = firstNonEmpty(process.env.HWLAB_CODE_AGENT_CODEX_API_FORWARDER_HOST, "127.0.0.1"); +const upstreamBaseUrl = new URL(firstNonEmpty(process.env.HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL, "https://hyueapi.com")); +const upstreamNoProxyEntries = noProxyEntriesForHost(upstreamBaseUrl.hostname); +const maxBodyBytes = parsePositiveInteger(process.env.HWLAB_CODE_AGENT_CODEX_API_FORWARDER_MAX_BODY_BYTES, 64 * 1024 * 1024); + +const hopByHopHeaders = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]); + +ensureNoProxyEntries(upstreamNoProxyEntries); + +const server = createServer(async (clientReq, clientRes) => { + try { + const url = new URL(clientReq.url || "/", "http://hwlab-codex-api-forwarder.local"); + if (url.pathname === "/health" || url.pathname === "/health/live" || url.pathname === "/health/liveliness") { + sendJson(clientRes, 200, healthPayload("ok")); + return; + } + if (url.pathname === "/health/readiness") { + sendJson(clientRes, 200, healthPayload("ready")); + return; + } + await proxyRequest(clientReq, clientRes, url); + } catch (error) { + sendJson(clientRes, 502, { + error: { + code: "codex_api_forwarder_failed", + message: "Codex API Responses forwarder failed to proxy request", + reason: error.message + } + }); + } +}); + +server.listen(port, host, () => { + process.stdout.write(JSON.stringify({ + serviceId, + status: "listening", + host, + port, + upstreamHost: upstreamBaseUrl.hostname, + noProxyRequired: upstreamNoProxyEntries + }) + "\n"); +}); + +async function proxyRequest(clientReq, clientRes, url) { + const startedAt = Date.now(); + const targetUrl = targetUrlForRequest(url); + const headers = filterRequestHeaders(clientReq.headers, targetUrl); + 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); + let responseBytes = 0; + upstreamRes.on("data", (chunk) => { + responseBytes += chunk.length; + }); + upstreamRes.on("end", () => { + process.stderr.write(JSON.stringify({ + serviceId, + method: clientReq.method, + path: url.pathname, + upstreamPath: targetUrl.pathname, + status: upstreamRes.statusCode || null, + responseBytes, + elapsedMs: Date.now() - startedAt + }) + "\n"); + }); + upstreamRes.pipe(clientRes); + }); + + upstreamReq.on("error", (error) => { + if (!clientRes.headersSent) { + sendJson(clientRes, 502, { + error: { + code: "codex_api_forwarder_upstream_error", + message: "Codex API forwarder upstream request failed", + reason: error.message + } + }); + } else { + clientRes.end(); + } + }); + + await pipeRequestBody(clientReq, upstreamReq); +} + +async function pipeRequestBody(clientReq, upstreamReq) { + let bytes = 0; + for await (const chunk of clientReq) { + bytes += chunk.length; + if (bytes > maxBodyBytes) { + upstreamReq.destroy(new Error(`request body exceeded ${maxBodyBytes} bytes`)); + return; + } + if (!upstreamReq.write(chunk)) { + await new Promise((resolve) => upstreamReq.once("drain", resolve)); + } + } + upstreamReq.end(); +} + +function targetUrlForRequest(url) { + const target = new URL(upstreamBaseUrl.toString()); + target.pathname = upstreamPathFor(url.pathname); + target.search = url.search; + target.hash = ""; + return target; +} + +function upstreamPathFor(inboundPath) { + const basePath = upstreamBaseUrl.pathname.replace(/\/+$/u, ""); + const requestPath = normalizeCodexResponsesPath(inboundPath || "/"); + if (!basePath) return requestPath; + if (basePath === "/v1" && requestPath.startsWith("/v1/")) return requestPath; + if (basePath === "/v1" && requestPath === "/v1") return requestPath; + if (basePath.endsWith("/responses") && (requestPath === "/responses" || requestPath === "/v1/responses")) return basePath; + if (basePath.endsWith("/models") && (requestPath === "/models" || requestPath === "/v1/models")) return basePath; + if (requestPath.startsWith(`${basePath}/`) || requestPath === basePath) return requestPath; + return `${basePath}${requestPath}`; +} + +function normalizeCodexResponsesPath(pathname) { + if (upstreamBaseUrl.pathname.replace(/\/+$/u, "") === "/v1") return pathname; + if (pathname === "/v1/responses") return "/responses"; + if (pathname === "/v1/models") return "/models"; + return pathname; +} + +function filterRequestHeaders(headers, targetUrl) { + const filtered = {}; + for (const [name, value] of Object.entries(headers)) { + const lower = name.toLowerCase(); + if (hopByHopHeaders.has(lower) || lower === "host") continue; + filtered[name] = value; + } + filtered.host = targetUrl.host; + return filtered; +} + +function filterResponseHeaders(headers) { + const filtered = {}; + for (const [name, value] of Object.entries(headers)) { + if (hopByHopHeaders.has(name.toLowerCase())) continue; + filtered[name] = value; + } + return filtered; +} + +function sendJson(res, status, payload) { + const body = JSON.stringify(payload); + res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body) }); + res.end(body); +} + +function healthPayload(status) { + return { + serviceId, + status, + listen: { host, port }, + upstream: { + origin: upstreamBaseUrl.origin, + host: upstreamBaseUrl.hostname, + directNoProxyRequired: true, + noProxyRequired: upstreamNoProxyEntries + } + }; +} + +function ensureNoProxyEntries(entries) { + for (const name of ["NO_PROXY", "no_proxy"]) { + process.env[name] = mergeNoProxyEntries(process.env[name], entries); + } +} + +function noProxyEntriesForHost(hostname) { + const hostText = String(hostname || "").trim().toLowerCase(); + if (!hostText) return ["127.0.0.1", "localhost", "::1"]; + const entries = [hostText, "127.0.0.1", "localhost", "::1"]; + if (!/^\d+(?:\.\d+){3}$/u.test(hostText) && !hostText.includes(":")) entries.push(`.${hostText}`); + return uniqueStrings(entries); +} + +function mergeNoProxyEntries(current, additions = []) { + return uniqueStrings([...String(current ?? "").split(/[,;\s]+/u), ...additions]).join(","); +} + +function uniqueStrings(values) { + const seen = new Set(); + const result = []; + for (const value of values) { + const text = String(value ?? "").trim(); + if (!text) continue; + const key = text.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(text); + } + return result; +} + +function firstNonEmpty(...values) { + for (const value of values) { + const text = String(value ?? "").trim(); + if (text) return text; + } + return ""; +} + +function parsePositiveInteger(value, fallback) { + const parsed = Number.parseInt(value || "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} diff --git a/cmd/hwlab-codex-api-responses-forwarder/main.test.mjs b/cmd/hwlab-codex-api-responses-forwarder/main.test.mjs new file mode 100644 index 00000000..158c3281 --- /dev/null +++ b/cmd/hwlab-codex-api-responses-forwarder/main.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import test from "node:test"; + +test("Codex API Responses forwarder maps loopback Codex paths to configurable upstream and preserves stream body", async () => { + const upstream = await startUpstream(); + const forwarder = await startForwarder(upstream.port, { upstreamPath: "/" }); + try { + const response = await fetch(`http://127.0.0.1:${forwarder.port}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-secret" + }, + body: JSON.stringify({ model: "gpt-5.5", input: "OK" }) + }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.ok, true); + assert.equal(upstream.captured.url, "/responses"); + assert.equal(upstream.captured.authorization, "Bearer test-secret"); + assert.deepEqual(JSON.parse(upstream.captured.body), { model: "gpt-5.5", input: "OK" }); + + const health = await fetch(`http://127.0.0.1:${forwarder.port}/health/live`).then((item) => item.json()); + assert.equal(health.upstream.host, "127.0.0.1"); + assert.equal(health.upstream.directNoProxyRequired, true); + } finally { + await forwarder.stop(); + await upstream.stop(); + } +}); + +test("Codex API Responses forwarder keeps /v1 prefix when upstream base is /v1", async () => { + const upstream = await startUpstream(); + const forwarder = await startForwarder(upstream.port, { upstreamPath: "/v1" }); + try { + const response = await fetch(`http://127.0.0.1:${forwarder.port}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ input: "OK" }) + }); + assert.equal(response.status, 200); + assert.equal(upstream.captured.url, "/v1/responses"); + } finally { + await forwarder.stop(); + await upstream.stop(); + } +}); + +async function startUpstream() { + let captured = null; + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + captured = { + url: request.url, + method: request.method, + authorization: request.headers.authorization || null, + body: Buffer.concat(chunks).toString("utf8") + }; + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + port: server.address().port, + get captured() { + return captured; + }, + stop: () => new Promise((resolve) => server.close(resolve)) + }; +} + +async function startForwarder(upstreamPort, { upstreamPath }) { + const port = await freePort(); + const child = spawn(process.execPath, ["cmd/hwlab-codex-api-responses-forwarder/main.mjs"], { + cwd: process.cwd(), + env: { + ...process.env, + HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT: String(port), + HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL: `http://127.0.0.1:${upstreamPort}${upstreamPath}` + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`forwarder did not start: ${stderr}`)), 5000); + child.stdout.on("data", (chunk) => { + if (chunk.toString().includes("listening")) { + clearTimeout(timer); + resolve(); + } + }); + child.on("exit", (code) => { + clearTimeout(timer); + reject(new Error(`forwarder exited early code=${code}: ${stderr}`)); + }); + }); + return { + port, + stop: async () => { + child.kill("SIGTERM"); + await new Promise((resolve) => child.once("exit", resolve)); + } + }; +} + +async function freePort() { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + await new Promise((resolve) => server.close(resolve)); + return port; +} diff --git a/deploy/deploy.json b/deploy/deploy.json index 1c56cc16..c21bb41c 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -206,7 +206,7 @@ "HWLAB_CODE_AGENT_PROVIDER": "codex-stdio", "HWLAB_CODE_AGENT_MODEL": "gpt-5.5", "HWLAB_CODE_AGENT_TIMEOUT_MS": "600000", - "HWLAB_CODE_AGENT_OPENAI_BASE_URL": "http://172.26.26.227:17680/v1/responses", + "HWLAB_CODE_AGENT_OPENAI_BASE_URL": "http://127.0.0.1:49280/responses", "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", "HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED": "1", "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR": "repo-owned", @@ -218,7 +218,14 @@ "NO_PROXY": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com", "no_proxy": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com", "OPENAI_API_KEY": "secretRef:hwlab-code-agent-provider/openai-api-key", - "HWLAB_CODE_AGENT_SKILLS_DIRS": "/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills" + "HWLAB_CODE_AGENT_SKILLS_DIRS": "/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills", + "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE": "deepseek", + "HWLAB_CODE_AGENT_DEEPSEEK_MODEL": "deepseek-chat", + "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL": "http://hwlab-deepseek-proxy.hwlab-dev.svc.cluster.local:4000/v1/responses", + "HWLAB_CODE_AGENT_CODEX_API_MODEL": "gpt-5.5", + "HWLAB_CODE_AGENT_CODEX_API_BASE_URL": "http://127.0.0.1:49280/responses", + "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL": "https://hyueapi.com", + "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT": "49280" } }, { diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 08bf54f1..58f80685 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -414,9 +414,37 @@ "type": "string", "const": "gpt-5.5" }, + "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE": { + "type": "string", + "const": "deepseek" + }, + "HWLAB_CODE_AGENT_DEEPSEEK_MODEL": { + "type": "string", + "const": "deepseek-chat" + }, + "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL": { + "type": "string", + "const": "http://hwlab-deepseek-proxy.hwlab-dev.svc.cluster.local:4000/v1/responses" + }, + "HWLAB_CODE_AGENT_CODEX_API_MODEL": { + "type": "string", + "const": "gpt-5.5" + }, + "HWLAB_CODE_AGENT_CODEX_API_BASE_URL": { + "type": "string", + "const": "http://127.0.0.1:49280/responses" + }, + "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL": { + "type": "string", + "const": "https://hyueapi.com" + }, + "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT": { + "type": "string", + "const": "49280" + }, "HWLAB_CODE_AGENT_OPENAI_BASE_URL": { "type": "string", - "const": "http://172.26.26.227:17680/v1/responses" + "const": "http://127.0.0.1:49280/responses" }, "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL": { "type": "string", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index abc464dc..1e67f757 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -123,7 +123,7 @@ }, { "name": "HWLAB_CODE_AGENT_OPENAI_BASE_URL", - "value": "http://172.26.26.227:17680/v1/responses" + "value": "http://127.0.0.1:49280/responses" }, { "name": "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", @@ -178,6 +178,34 @@ "optional": true } } + }, + { + "name": "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", + "value": "deepseek" + }, + { + "name": "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", + "value": "deepseek-chat" + }, + { + "name": "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", + "value": "http://hwlab-deepseek-proxy.hwlab-dev.svc.cluster.local:4000/v1/responses" + }, + { + "name": "HWLAB_CODE_AGENT_CODEX_API_MODEL", + "value": "gpt-5.5" + }, + { + "name": "HWLAB_CODE_AGENT_CODEX_API_BASE_URL", + "value": "http://127.0.0.1:49280/responses" + }, + { + "name": "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", + "value": "https://hyueapi.com" + }, + { + "name": "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", + "value": "49280" } ], "readinessProbe": { @@ -214,6 +242,54 @@ "readOnly": true } ] + }, + { + "name": "hwlab-codex-api-forwarder", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:6a84959", + "command": [ + "node", + "/app/cmd/hwlab-codex-api-responses-forwarder/main.mjs" + ], + "ports": [ + { + "name": "codex-api", + "containerPort": 49280 + } + ], + "env": [ + { + "name": "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_HOST", + "value": "127.0.0.1" + }, + { + "name": "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", + "value": "49280" + }, + { + "name": "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", + "value": "https://hyueapi.com" + }, + { + "name": "NO_PROXY", + "value": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com" + }, + { + "name": "no_proxy", + "value": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/health/readiness", + "port": "codex-api" + } + }, + "livenessProbe": { + "httpGet": { + "path": "/health/live", + "port": "codex-api" + } + } } ], "volumes": [ diff --git a/internal/cloud/code-agent-contract.mjs b/internal/cloud/code-agent-contract.mjs index 1257ab87..15d8ff99 100644 --- a/internal/cloud/code-agent-contract.mjs +++ b/internal/cloud/code-agent-contract.mjs @@ -10,7 +10,14 @@ export const DEV_CODE_AGENT_PROVIDER_CONTRACT = Object.freeze({ model: "gpt-5.5", requiredEnv: Object.freeze([ "OPENAI_API_KEY", - "HWLAB_CODE_AGENT_OPENAI_BASE_URL" + "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", + "HWLAB_CODE_AGENT_OPENAI_BASE_URL", + "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", + "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", + "HWLAB_CODE_AGENT_CODEX_API_MODEL", + "HWLAB_CODE_AGENT_CODEX_API_BASE_URL", + "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", + "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT" ]), secretRefs: Object.freeze([ Object.freeze({ @@ -21,10 +28,32 @@ export const DEV_CODE_AGENT_PROVIDER_CONTRACT = Object.freeze({ ]), egress: Object.freeze({ env: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", - target: "dev-egress-proxy", - defaultBaseUrl: "http://172.26.26.227:17680/v1/responses", + target: "pod-local-codex-api-forwarder", + defaultBaseUrl: "http://127.0.0.1:49280/responses", + upstreamEnv: "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", + upstreamBaseUrl: "https://hyueapi.com", forbiddenDirectBaseUrl: "https://api.openai.com/v1/responses", - reason: "DEV cloud-api pods must use the D601 egress/proxy path for OpenAI Responses." + reason: "DEV cloud-api pods must send codex-api traffic to a same-Pod loopback forwarder; the forwarder direct-connects to hyueapi with NO_PROXY." + }), + profiles: Object.freeze({ + defaultProfile: "deepseek", + deepSeekProfile: "deepseek", + deepSeekModelEnv: "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", + deepSeekBaseUrlEnv: "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", + deepSeekBaseUrl: "http://hwlab-deepseek-proxy.hwlab-dev.svc.cluster.local:4000/v1/responses", + codexApiProfile: "codex-api", + codexApiModelEnv: "HWLAB_CODE_AGENT_CODEX_API_MODEL", + codexApiBaseUrlEnv: "HWLAB_CODE_AGENT_CODEX_API_BASE_URL" + }), + forwarder: Object.freeze({ + containerName: "hwlab-codex-api-forwarder", + command: Object.freeze(["node", "/app/cmd/hwlab-codex-api-responses-forwarder/main.mjs"]), + port: 49280, + portName: "codex-api", + listenHostEnv: "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_HOST", + portEnv: "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", + upstreamEnv: "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", + upstreamBaseUrl: "https://hyueapi.com" }), codexHome: Object.freeze({ path: "/codex-home", @@ -65,6 +94,13 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE const expectedValues = { HWLAB_CODE_AGENT_PROVIDER: contract.codeAgentProvider, HWLAB_CODE_AGENT_MODEL: contract.model, + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: contract.profiles.defaultProfile, + [contract.profiles.deepSeekModelEnv]: "deepseek-chat", + [contract.profiles.deepSeekBaseUrlEnv]: contract.profiles.deepSeekBaseUrl, + [contract.profiles.codexApiModelEnv]: contract.model, + [contract.profiles.codexApiBaseUrlEnv]: contract.egress.defaultBaseUrl, + [contract.forwarder.upstreamEnv]: contract.forwarder.upstreamBaseUrl, + [contract.forwarder.portEnv]: String(contract.forwarder.port), [contract.egress.env]: contract.egress.defaultBaseUrl, CODEX_HOME: contract.codexHome.path, NO_PROXY: contract.noProxyRequired.join(","), @@ -115,6 +151,7 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE ...noProxyMissing(deployEnv?.no_proxy, contract.noProxyRequired), ...workloadInspection.noProxy.missing ])]; + const missingForwarderContract = workloadInspection.forwarder.present ? [] : [contract.forwarder.containerName]; const ready = missingDeployEnv.length === 0 && @@ -123,7 +160,8 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE missingSecretRefs.length === 0 && missingEgressContract.length === 0 && missingCodexHomeContract.length === 0 && - missingNoProxyContract.length === 0; + missingNoProxyContract.length === 0 && + missingForwarderContract.length === 0; return { contractVersion: contract.contractVersion, @@ -160,6 +198,12 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE directPublicOpenAi: isForbiddenDirectOpenAiBaseUrl(deployEnv?.[contract.egress.env]) || workloadInspection.egress.directPublicOpenAi === true, + upstream: { + env: contract.egress.upstreamEnv, + deployMatchesDirectHyueapi: deployEnv?.[contract.egress.upstreamEnv] === contract.egress.upstreamBaseUrl, + workloadMatchesDirectHyueapi: workloadInspection.egress.upstreamMatchesDirectHyueapi, + valueRedacted: true + }, valueRedacted: true }, missingDeployEnv, @@ -170,7 +214,9 @@ export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadE missingEgressContract, missingCodexHomeContract, missingNoProxyContract, + missingForwarderContract, codexHome: workloadInspection.codexHome, + forwarder: workloadInspection.forwarder, noProxy: workloadInspection.noProxy, secretMaterialRead: false, valuesRedacted: true, @@ -186,6 +232,13 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { const expectedValues = { HWLAB_CODE_AGENT_PROVIDER: contract.codeAgentProvider, HWLAB_CODE_AGENT_MODEL: contract.model, + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: contract.profiles.defaultProfile, + [contract.profiles.deepSeekModelEnv]: "deepseek-chat", + [contract.profiles.deepSeekBaseUrlEnv]: contract.profiles.deepSeekBaseUrl, + [contract.profiles.codexApiModelEnv]: contract.model, + [contract.profiles.codexApiBaseUrlEnv]: contract.egress.defaultBaseUrl, + [contract.forwarder.upstreamEnv]: contract.forwarder.upstreamBaseUrl, + [contract.forwarder.portEnv]: String(contract.forwarder.port), [contract.egress.env]: contract.egress.defaultBaseUrl, CODEX_HOME: contract.codexHome.path, NO_PROXY: contract.noProxyRequired.join(","), @@ -218,24 +271,29 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { secretKeyRef?.name === secretRef.secretName && secretKeyRef?.key === secretRef.secretKey; const baseUrl = envEntryValue(getEnvEntry(workloadEnv, contract.egress.env)); + const upstreamBaseUrl = envEntryValue(getEnvEntry(workloadEnv, contract.egress.upstreamEnv)); const directPublicOpenAi = isForbiddenDirectOpenAiBaseUrl(baseUrl); const matchesDevProxy = baseUrl === contract.egress.defaultBaseUrl; + const upstreamMatchesDirectHyueapi = upstreamBaseUrl === contract.egress.upstreamBaseUrl; const codexHomePresent = envEntryValue(getEnvEntry(workloadEnv, "CODEX_HOME")) === contract.codexHome.path; const noProxyMissingEntries = [...new Set([ ...noProxyMissing(envEntryValue(getEnvEntry(workloadEnv, "NO_PROXY")), contract.noProxyRequired), ...noProxyMissing(envEntryValue(getEnvEntry(workloadEnv, "no_proxy")), contract.noProxyRequired) ])]; const codexHome = inspectCodexHomeWorkloadMounts(workloadEnv); + const forwarder = inspectCodexApiForwarderWorkload(workloadEnv); const ready = missingEnv.length === 0 && mismatchedEnv.length === 0 && secretPresent && matchesDevProxy && + upstreamMatchesDirectHyueapi && !directPublicOpenAi && codexHome.present === true && codexHome.configMapPresent === true && codexHome.authSecretPresent === true && - noProxyMissingEntries.length === 0; + noProxyMissingEntries.length === 0 && + forwarder.present === true; return { contractVersion: contract.contractVersion, @@ -261,6 +319,7 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { target: contract.egress.target, present: Boolean(baseUrl), matchesDevProxy, + upstreamMatchesDirectHyueapi, directPublicOpenAi, valueRedacted: true }, @@ -268,6 +327,7 @@ export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { ...codexHome, present: codexHomePresent && codexHome.present === true }, + forwarder, noProxy: { required: [...contract.noProxyRequired], missing: noProxyMissingEntries, @@ -291,6 +351,11 @@ export function buildCodeAgentProviderManifestPlaceholder() { requiredEnv: [...contract.requiredEnv], secretRefs: contract.secretRefs.map((item) => ({ ...item })), egress: { ...contract.egress }, + profiles: { ...contract.profiles }, + forwarder: { + ...contract.forwarder, + command: [...contract.forwarder.command] + }, codexHome: { ...contract.codexHome }, noProxyRequired: [...contract.noProxyRequired], fixtureEvidence: { @@ -305,10 +370,17 @@ export function inspectCodeAgentProviderEnv(env = {}) { const missingEnv = contract.requiredEnv.filter((name) => !hasEnvValue(env, name)); const secretRef = contract.secretRefs[0]; const baseUrl = firstNonEmpty(env.HWLAB_CODE_AGENT_OPENAI_BASE_URL); + const codexApiBaseUrl = firstNonEmpty(env[contract.profiles.codexApiBaseUrlEnv]); + const upstreamBaseUrl = firstNonEmpty(env[contract.forwarder.upstreamEnv]); + const forwarderPort = firstNonEmpty(env[contract.forwarder.portEnv]); const directPublicOpenAi = baseUrl === contract.egress.forbiddenDirectBaseUrl || /^https:\/\/api\.openai\.com\/v1\/responses\/?$/u.test(baseUrl); - const egressReady = Boolean(baseUrl) && !directPublicOpenAi; + const egressMatchesForwarder = baseUrl === contract.egress.defaultBaseUrl; + const codexApiProfileReady = codexApiBaseUrl === contract.egress.defaultBaseUrl; + const upstreamReady = upstreamBaseUrl === contract.forwarder.upstreamBaseUrl; + const forwarderPortReady = forwarderPort === String(contract.forwarder.port); + const egressReady = Boolean(baseUrl) && !directPublicOpenAi && egressMatchesForwarder && codexApiProfileReady && upstreamReady && forwarderPortReady; const noProxyReady = noProxyMissing(env.NO_PROXY, contract.noProxyRequired).length === 0 && noProxyMissing(env.no_proxy, contract.noProxyRequired).length === 0; const codexHomeReady = firstNonEmpty(env.CODEX_HOME) === contract.codexHome.path; @@ -347,9 +419,22 @@ export function inspectCodeAgentProviderEnv(env = {}) { target: contract.egress.target, baseUrlConfigured: Boolean(baseUrl), directPublicOpenAi, + matchesForwarder: egressMatchesForwarder, + codexApiProfileReady, + upstreamReady, + forwarderPortReady, ready: egressReady, valueRedacted: true }, + forwarder: { + containerName: contract.forwarder.containerName, + expectedBaseUrl: contract.egress.defaultBaseUrl, + upstreamEnv: contract.forwarder.upstreamEnv, + expectedUpstreamHost: new URL(contract.forwarder.upstreamBaseUrl).hostname, + managedByKubernetes: true, + podLocalOnly: true, + serviceRequired: false + }, codexHome: { path: contract.codexHome.path, present: codexHomeReady, @@ -374,16 +459,19 @@ export function inspectCodeAgentProviderEnv(env = {}) { valuesRedacted: true, directPublicOpenAiFromDevPod: directPublicOpenAi }, - blocker: ready ? null : codeAgentProviderBlocker({ missingEnv, directPublicOpenAi, baseUrl }) + blocker: ready ? null : codeAgentProviderBlocker({ missingEnv, directPublicOpenAi, baseUrl, egressMatchesForwarder, codexApiProfileReady, upstreamReady, forwarderPortReady }) }; } -function codeAgentProviderBlocker({ missingEnv, directPublicOpenAi, baseUrl }) { +function codeAgentProviderBlocker({ missingEnv, directPublicOpenAi, baseUrl, egressMatchesForwarder, codexApiProfileReady, upstreamReady, forwarderPortReady }) { if (directPublicOpenAi) { - return "DEV Code Agent OpenAI base URL points at public api.openai.com instead of the DEV egress/proxy path"; + return "DEV Code Agent OpenAI base URL points at public api.openai.com instead of the pod-local Codex API forwarder"; } if (!baseUrl) { - return "DEV Code Agent OpenAI base URL is missing; provider calls must use the DEV egress/proxy path"; + return "DEV Code Agent OpenAI base URL is missing; provider calls must use the pod-local Codex API forwarder"; + } + if (!egressMatchesForwarder || !codexApiProfileReady || !upstreamReady || !forwarderPortReady) { + return "DEV Code Agent codex-api profile must use the pod-local loopback forwarder with direct hyueapi upstream"; } return `Code Agent provider runtime config is missing ${missingEnv.join(", ")}`; } @@ -482,6 +570,44 @@ function noProxyMissing(value, required) { return required.filter((item) => !entries.has(String(item).toLowerCase())); } +function inspectCodexApiForwarderWorkload(workloadEnv = {}) { + const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT; + const containers = Array.isArray(workloadEnv.__podContainers) ? workloadEnv.__podContainers : []; + const sidecar = containers.find((entry) => entry?.name === contract.forwarder.containerName); + const env = new Map((sidecar?.env ?? []).map((entry) => [entry?.name, envEntryValue(entry)])); + const commandMatches = arrayEqual(sidecar?.command, contract.forwarder.command); + const portMatches = (sidecar?.ports ?? []).some((entry) => + entry?.name === contract.forwarder.portName && entry?.containerPort === contract.forwarder.port + ); + const upstreamMatches = env.get(contract.forwarder.upstreamEnv) === contract.forwarder.upstreamBaseUrl; + const listenHostMatches = env.get(contract.forwarder.listenHostEnv) === "127.0.0.1"; + const portEnvMatches = env.get(contract.forwarder.portEnv) === String(contract.forwarder.port); + const noProxyReady = noProxyMissing(env.get("NO_PROXY"), contract.noProxyRequired).length === 0 && + noProxyMissing(env.get("no_proxy"), contract.noProxyRequired).length === 0; + const present = Boolean(sidecar && commandMatches && portMatches && upstreamMatches && listenHostMatches && portEnvMatches && noProxyReady); + return { + containerName: contract.forwarder.containerName, + present, + sidecarPresent: Boolean(sidecar), + commandMatches, + portMatches, + upstreamMatches, + listenHostMatches, + portEnvMatches, + noProxyReady, + podLocalOnly: true, + managedByKubernetes: true, + serviceRequired: false, + valueRedacted: true + }; +} + +function arrayEqual(actual, expected) { + if (!Array.isArray(actual) || !Array.isArray(expected)) return false; + if (actual.length !== expected.length) return false; + return actual.every((item, index) => Object.is(item, expected[index])); +} + function isForbiddenDirectOpenAiBaseUrl(value) { const baseUrl = firstNonEmpty(value); return ( diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index bf8fa853..f957f944 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -768,7 +768,7 @@ test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardw HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" }, sessionRegistry: registry, codexStdioManager: manager, @@ -850,7 +850,7 @@ test("Codex stdio manager rebuilds app-server client when provider profile confi env: { ...baseEnv, HWLAB_CODE_AGENT_MODEL: "gpt-5.5", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" } }); @@ -899,7 +899,7 @@ test("Code Agent Keil gateway prompt is not blocked by M3 IO intent guard", asyn HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" }, sessionRegistry: registry, codexStdioManager: manager, @@ -1207,7 +1207,7 @@ test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and versi HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_CODEX_SANDBOX: "workspace-write", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" } }); @@ -1247,17 +1247,17 @@ test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and versi test("Codex app-server args pin repo-owned DEV responses egress without leaking provider env", () => { const env = { - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses", HWLAB_CODE_AGENT_MODEL: "deepseek-chat", OPENAI_API_KEY: "test-openai-key-material" }; - assert.equal(codexAppServerProviderBaseUrl(env), "http://172.26.26.227:17680/v1"); + assert.equal(codexAppServerProviderBaseUrl(env), "http://127.0.0.1:49280"); const args = codexAppServerArgs(env); assert.ok(args.indexOf("app-server") > args.indexOf("review_model=\"deepseek-chat\"")); assert.deepEqual(args.slice(-2), ["--listen", "stdio://"]); assert.ok(args.includes("model_provider=\"OpenAI\"")); - assert.ok(args.includes("model_providers.OpenAI.base_url=\"http://172.26.26.227:17680/v1\"")); + assert.ok(args.includes("model_providers.OpenAI.base_url=\"http://127.0.0.1:49280\"")); assert.ok(args.includes("model_providers.OpenAI.name=\"OpenAI\"")); assert.ok(args.includes("model_providers.OpenAI.wire_api=\"responses\"")); assert.ok(args.includes("model=\"deepseek-chat\"")); @@ -1315,7 +1315,7 @@ test("Codex stdio skills discovery returns bounded manifest facts instead of gen HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_SKILLS_DIRS: skillsDir, HWLAB_CODE_AGENT_SKILLS_STRICT: "1", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" } } ); @@ -1389,7 +1389,7 @@ test("Codex stdio skills discovery returns structured blocker when manifests are HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_SKILLS_DIRS: path.join(root, "missing-skills"), HWLAB_CODE_AGENT_SKILLS_STRICT: "1", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" } } ); @@ -1450,7 +1450,7 @@ test("Codex stdio runner startup failure marks session failed and blocks long-li HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" }; const payload = await handleCodeAgentChat( @@ -1510,7 +1510,7 @@ test("Codex app-server retrying error notification waits for completed turn", as HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" }; const payload = await handleCodeAgentChat( @@ -1565,7 +1565,7 @@ test("Codex app-server activity timeout does not fail an active turn", async () HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" }; try { @@ -1620,7 +1620,7 @@ test("Codex app-server activity timeout fails only after no new events", async ( HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" }; try { @@ -1683,7 +1683,7 @@ test("Codex app-server partial assistant output without turn completion is a tim HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" }; try { @@ -1740,7 +1740,7 @@ test("Codex app-server failed turn is not wrapped as completed assistant output" HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" }; const payload = await handleCodeAgentChat( diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index a2c2233b..d26aee91 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -58,7 +58,7 @@ const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({ "runtime-default": "运行默认" }); const DEFAULT_CODE_AGENT_CODEX_API_MODEL = "gpt-5.5"; -const DEFAULT_CODE_AGENT_CODEX_API_BASE_URL = "http://172.26.26.227:17680/v1/responses"; +const DEFAULT_CODE_AGENT_CODEX_API_BASE_URL = "http://127.0.0.1:49280/responses"; const DEFAULT_CODE_AGENT_DEEPSEEK_MODEL = "deepseek-chat"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const LIVE_BUILD_METADATA_PATHS = Object.freeze({ @@ -1355,7 +1355,23 @@ function defaultDeepSeekBaseUrlForEnv(env = process.env) { function codeAgentClusterNoProxyEntries(env = process.env) { const namespace = env.HWLAB_GITOPS_PROFILE === "prod" || env.HWLAB_ENVIRONMENT === "prod" ? "hwlab-prod" : "hwlab-dev"; - return [`${namespace}.svc.cluster.local`, ".svc", ".cluster.local", "127.0.0.1", "localhost", "::1", "10.0.0.0/8", "10.42.0.0/16", "10.43.0.0/16", "172.26.26.227", "172.26.0.0/16"]; + return [ + `${namespace}.svc.cluster.local`, + ".svc", + ".cluster.local", + "127.0.0.1", + "localhost", + "::1", + "10.0.0.0/8", + "10.42.0.0/16", + "10.43.0.0/16", + "hyueapi.com", + ".hyueapi.com", + "hyui.com", + ".hyui.com", + "api.minimaxi.com", + ".minimaxi.com" + ]; } function mergeNoProxyEntries(current, additions = []) { diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index ecfde998..5c8eee1d 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -309,12 +309,12 @@ async function runLocalContractSmoke() { logOk("providerStatus=401 blocks even when readiness fields look feasible"); const providerEnv = { - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses", OPENAI_API_KEY: "test-openai-key-material" }; - assert.equal(codexAppServerProviderBaseUrl(providerEnv), "http://172.26.26.227:17680/v1"); + assert.equal(codexAppServerProviderBaseUrl(providerEnv), "http://127.0.0.1:49280"); const providerArgs = codexAppServerArgs(providerEnv).join(" "); - assert.match(providerArgs, /model_providers\.OpenAI\.base_url="http:\/\/172\.26\.26\.227:17680\/v1"/u); + assert.match(providerArgs, /model_providers\.OpenAI\.base_url="http:\/\/127\.0\.0\.1:49280"/u); assert.match(providerArgs, /model_providers\.OpenAI\.name="OpenAI"/u); assert.match(providerArgs, /model_providers\.OpenAI\.wire_api="responses"/u); assert.match(providerArgs, /model_providers\.OpenAI\.requires_openai_auth=true/u); diff --git a/scripts/deploy-desired-state-plan.test.mjs b/scripts/deploy-desired-state-plan.test.mjs index 32a7b0ac..6b6c537b 100644 --- a/scripts/deploy-desired-state-plan.test.mjs +++ b/scripts/deploy-desired-state-plan.test.mjs @@ -21,6 +21,7 @@ async function git(repoRoot, args) { async function makeFixture({ serviceId = "hwlab-cloud-api", commitId = "abc1234", + deployImageTag = commitId, catalogCommitId = commitId, catalogServiceCommitId = catalogCommitId, catalogImageTag = catalogServiceCommitId.slice(0, 7), @@ -46,19 +47,27 @@ async function makeFixture({ } = {}) { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-desired-state-")); await mkdir(path.join(root, "deploy/k8s/base"), { recursive: true }); - const image = `127.0.0.1:5000/hwlab/${serviceId}:${commitId}`; + const image = `127.0.0.1:5000/hwlab/${serviceId}:${deployImageTag}`; const catalogImage = `127.0.0.1:5000/hwlab/${serviceId}:${catalogImageTag}`; const workloadImage = `127.0.0.1:5000/hwlab/${serviceId}:${workloadTag}`; - const providerBaseUrl = "http://172.26.26.227:17680/v1/responses"; + const providerBaseUrl = "http://127.0.0.1:49280/responses"; + const providerNoProxy = "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com"; const providerEnv = serviceId === "hwlab-cloud-api" && deployCodeAgentProviderEnv ? { HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-5.5", + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", + HWLAB_CODE_AGENT_DEEPSEEK_MODEL: "deepseek-chat", + HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL: "http://hwlab-deepseek-proxy.hwlab-dev.svc.cluster.local:4000/v1/responses", + HWLAB_CODE_AGENT_CODEX_API_MODEL: "gpt-5.5", + HWLAB_CODE_AGENT_CODEX_API_BASE_URL: providerBaseUrl, + HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL: "https://hyueapi.com", + HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT: "49280", HWLAB_CODE_AGENT_OPENAI_BASE_URL: providerBaseUrl, CODEX_HOME: "/codex-home", - NO_PROXY: "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com", - no_proxy: "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com", + NO_PROXY: providerNoProxy, + no_proxy: providerNoProxy, OPENAI_API_KEY: "secretRef:hwlab-code-agent-provider/openai-api-key" } : {}; @@ -67,10 +76,17 @@ async function makeFixture({ ? [ { name: "HWLAB_CODE_AGENT_PROVIDER", value: "codex-stdio" }, { name: "HWLAB_CODE_AGENT_MODEL", value: "gpt-5.5" }, + { name: "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", value: "deepseek" }, + { name: "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", value: "deepseek-chat" }, + { name: "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", value: "http://hwlab-deepseek-proxy.hwlab-dev.svc.cluster.local:4000/v1/responses" }, + { name: "HWLAB_CODE_AGENT_CODEX_API_MODEL", value: "gpt-5.5" }, + { name: "HWLAB_CODE_AGENT_CODEX_API_BASE_URL", value: providerBaseUrl }, + { name: "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", value: "https://hyueapi.com" }, + { name: "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", value: "49280" }, { name: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", value: providerBaseUrl }, { name: "CODEX_HOME", value: "/codex-home" }, - { name: "NO_PROXY", value: "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com" }, - { name: "no_proxy", value: "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com" }, + { name: "NO_PROXY", value: providerNoProxy }, + { name: "no_proxy", value: providerNoProxy }, { name: "OPENAI_API_KEY", valueFrom: { @@ -195,7 +211,24 @@ async function makeFixture({ ] } : {}) - } + }, + ...(serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv + ? [ + { + name: "hwlab-codex-api-forwarder", + image: workloadImage, + command: ["node", "/app/cmd/hwlab-codex-api-responses-forwarder/main.mjs"], + ports: [{ name: "codex-api", containerPort: 49280 }], + env: [ + { name: "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_HOST", value: "127.0.0.1" }, + { name: "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", value: "49280" }, + { name: "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", value: "https://hyueapi.com" }, + { name: "NO_PROXY", value: providerNoProxy }, + { name: "no_proxy", value: providerNoProxy } + ] + } + ] + : []) ], ...(serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv && workloadCodexHomeVolumes ? { @@ -227,6 +260,29 @@ test("passes internally consistent desired-state", async () => { assert.deepEqual(plan.diagnostics, []); }); +test("passes mixed desired-state when unchanged services reuse service-level artifact tags", async () => { + const repoRoot = await makeFixture({ + commitId: "803ba22", + deployImageTag: "6a84959", + catalogCommitId: "803ba22", + catalogServiceCommitId: "6a84959", + catalogImageTag: "6a84959", + deployEnvCommitId: "6a84959", + deployEnvImageTag: "6a84959", + workloadTag: "6a84959", + workloadEnvCommitId: "6a84959", + workloadEnvImageTag: "6a84959" + }); + + const plan = await buildDesiredStatePlan({ repoRoot }); + + assert.equal(plan.status, "pass"); + assert.equal(plan.summary.desiredCommitId, "803ba22"); + assert.equal(plan.services[0].deploy.imageTag, "6a84959"); + assert.equal(plan.services[0].catalog.commitId, "6a84959"); + assert.deepEqual(plan.diagnostics, []); +}); + test("passes when cloud-web owns runtime identity mirrors", async () => { const repoRoot = await makeFixture({ serviceId: "hwlab-cloud-web" }); const plan = await buildDesiredStatePlan({ repoRoot }); diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index aa4f563d..86d52993 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -20,7 +20,7 @@ const defaultProdRuntimeEndpoint = process.env.HWLAB_G14_PROD_RUNTIME_ENDPOINT | const defaultProdWebEndpoint = process.env.HWLAB_G14_PROD_WEB_ENDPOINT || "http://74.48.78.17:18666"; const defaultProxyUrl = process.env.HWLAB_G14_PROXY_URL || "http://127.0.0.1:10808"; const defaultAllProxyUrl = process.env.HWLAB_G14_ALL_PROXY_URL || "socks5h://127.0.0.1:10808"; -const defaultNoProxy = process.env.HWLAB_G14_NO_PROXY || "127.0.0.1,localhost,::1,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,192.168.0.0/16,.svc,.cluster.local,kubernetes.default.svc,registry.hwlab-ci.svc,hwlab-registry.hwlab-ci.svc"; +const defaultNoProxy = process.env.HWLAB_G14_NO_PROXY || "hyueapi.com,.hyueapi.com,127.0.0.1,localhost,::1,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,192.168.0.0/16,.svc,.cluster.local,kubernetes.default.svc,registry.hwlab-ci.svc,hwlab-registry.hwlab-ci.svc"; 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 buildkitRunnerImage = process.env.HWLAB_G14_BUILDKIT_IMAGE || "moby/buildkit:rootless"; @@ -46,7 +46,9 @@ const moonBridgeSourceRef = process.env.HWLAB_G14_MOONBRIDGE_REF || "1b99888d3da 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"; +const codexApiProfileBaseUrl = process.env.HWLAB_G14_CODEX_API_PROFILE_BASE_URL || "http://127.0.0.1:49280/responses"; +const codexApiForwarderPort = process.env.HWLAB_G14_CODEX_API_FORWARDER_PORT || "49280"; +const codexApiForwarderUpstreamBaseUrl = process.env.HWLAB_G14_CODEX_API_UPSTREAM_BASE_URL || "https://hyueapi.com"; const sourceCommitPattern = /^[a-f0-9]{7,40}$/u; function proxyEnv() { @@ -286,6 +288,26 @@ function deepSeekBaseUrl(namespace) { return `http://hwlab-deepseek-proxy.${namespace}.svc.cluster.local:4000/v1/responses`; } +function codeAgentNoProxy(namespace) { + return [ + `${namespace}.svc.cluster.local`, + ".svc", + ".cluster.local", + "hyueapi.com", + ".hyueapi.com", + "hyui.com", + ".hyui.com", + "127.0.0.1", + "localhost", + "::1", + "10.0.0.0/8", + "10.42.0.0/16", + "10.43.0.0/16", + "api.minimaxi.com", + ".minimaxi.com" + ].join(","); +} + function namespaceScopedHost(value, namespace) { if (typeof value !== "string") return value; return value.replace(/\.hwlab-dev\.svc\.cluster\.local/gu, `.${namespace}.svc.cluster.local`); @@ -380,8 +402,10 @@ function transformWorkloads({ workloads, deploy, source, registryPrefix, runtime upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", deepSeekBaseUrl(namespace)); upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_MODEL", codexApiProfileModel); upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_BASE_URL", codexApiProfileBaseUrl); - upsertEnv(container.env, "NO_PROXY", `${namespace}.svc.cluster.local,.svc,.cluster.local,127.0.0.1,localhost,::1,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.26.26.227,172.26.0.0/16`); - upsertEnv(container.env, "no_proxy", `${namespace}.svc.cluster.local,.svc,.cluster.local,127.0.0.1,localhost,::1,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,172.26.26.227,172.26.0.0/16`); + upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", codexApiForwarderUpstreamBaseUrl); + upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", codexApiForwarderPort); + upsertEnv(container.env, "NO_PROXY", codeAgentNoProxy(namespace)); + upsertEnv(container.env, "no_proxy", codeAgentNoProxy(namespace)); } if (serviceId === "hwlab-cloud-web") { upsertEnv(container.env, "HWLAB_API_BASE_URL", `http://hwlab-cloud-api.${namespace}.svc.cluster.local:6667`); diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index cee6f6c5..270f50f6 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -379,13 +379,20 @@ function validateCloudApiCodeAgentSource(ctx, env) { const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT; expectEqual(ctx, env.HWLAB_CODE_AGENT_PROVIDER, contract.codeAgentProvider, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API Code Agent provider"); expectEqual(ctx, env.HWLAB_CODE_AGENT_MODEL, contract.model, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API Code Agent model"); + expectEqual(ctx, env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE, contract.profiles.defaultProfile, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", "cloud API default provider profile"); + expectEqual(ctx, env[contract.profiles.deepSeekModelEnv], "deepseek-chat", `$.services.hwlab-cloud-api.env.${contract.profiles.deepSeekModelEnv}`, "cloud API DeepSeek profile model"); + expectEqual(ctx, env[contract.profiles.deepSeekBaseUrlEnv], contract.profiles.deepSeekBaseUrl, `$.services.hwlab-cloud-api.env.${contract.profiles.deepSeekBaseUrlEnv}`, "cloud API DeepSeek profile base URL"); + expectEqual(ctx, env[contract.profiles.codexApiModelEnv], contract.model, `$.services.hwlab-cloud-api.env.${contract.profiles.codexApiModelEnv}`, "cloud API codex-api profile model"); + expectEqual(ctx, env[contract.profiles.codexApiBaseUrlEnv], contract.egress.defaultBaseUrl, `$.services.hwlab-cloud-api.env.${contract.profiles.codexApiBaseUrlEnv}`, "cloud API codex-api profile base URL"); + expectEqual(ctx, env[contract.forwarder.upstreamEnv], contract.forwarder.upstreamBaseUrl, `$.services.hwlab-cloud-api.env.${contract.forwarder.upstreamEnv}`, "cloud API codex-api upstream base URL"); + expectEqual(ctx, env[contract.forwarder.portEnv], String(contract.forwarder.port), `$.services.hwlab-cloud-api.env.${contract.forwarder.portEnv}`, "cloud API codex-api forwarder port"); expectEqual(ctx, env.HWLAB_CODE_AGENT_TIMEOUT_MS, codeAgentBackendTimeoutMs, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_TIMEOUT_MS", "cloud API Code Agent hard timeout budget"); expectEqual( ctx, env.HWLAB_CODE_AGENT_OPENAI_BASE_URL, contract.egress.defaultBaseUrl, "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL", - "cloud API Code Agent DEV egress/proxy base URL" + "cloud API Code Agent pod-local Codex API forwarder base URL" ); expectEqual( ctx, @@ -572,16 +579,24 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) { const secretRef = contract.secretRefs[0]; const container = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.containers?.[0]; const env = new Map((container?.env ?? []).map((entry) => [entry.name, entry])); + const podContainers = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.containers ?? []; const volumes = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.volumes ?? []; expectEqual(ctx, env.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.codeAgentProvider, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_PROVIDER", "cloud API workload Code Agent provider"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_MODEL")?.value, contract.model, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_MODEL", "cloud API workload Code Agent model"); + expectEqual(ctx, env.get("HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE")?.value, contract.profiles.defaultProfile, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", "cloud API workload default provider profile"); + expectEqual(ctx, env.get(contract.profiles.deepSeekModelEnv)?.value, "deepseek-chat", `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.${contract.profiles.deepSeekModelEnv}`, "cloud API workload DeepSeek profile model"); + expectEqual(ctx, env.get(contract.profiles.deepSeekBaseUrlEnv)?.value, contract.profiles.deepSeekBaseUrl, `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.${contract.profiles.deepSeekBaseUrlEnv}`, "cloud API workload DeepSeek profile base URL"); + expectEqual(ctx, env.get(contract.profiles.codexApiModelEnv)?.value, contract.model, `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.${contract.profiles.codexApiModelEnv}`, "cloud API workload codex-api profile model"); + expectEqual(ctx, env.get(contract.profiles.codexApiBaseUrlEnv)?.value, contract.egress.defaultBaseUrl, `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.${contract.profiles.codexApiBaseUrlEnv}`, "cloud API workload codex-api profile base URL"); + expectEqual(ctx, env.get(contract.forwarder.upstreamEnv)?.value, contract.forwarder.upstreamBaseUrl, `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.${contract.forwarder.upstreamEnv}`, "cloud API workload codex-api upstream base URL"); + expectEqual(ctx, env.get(contract.forwarder.portEnv)?.value, String(contract.forwarder.port), `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.${contract.forwarder.portEnv}`, "cloud API workload codex-api forwarder port"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_TIMEOUT_MS")?.value, codeAgentBackendTimeoutMs, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_TIMEOUT_MS", "cloud API workload Code Agent hard timeout budget"); expectEqual( ctx, env.get("HWLAB_CODE_AGENT_OPENAI_BASE_URL")?.value, contract.egress.defaultBaseUrl, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL", - "cloud API workload Code Agent DEV egress/proxy base URL" + "cloud API workload Code Agent pod-local Codex API forwarder base URL" ); expectEqual( ctx, @@ -599,6 +614,7 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) { expectEqual(ctx, env.get("CODEX_HOME")?.value, "/codex-home", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.CODEX_HOME", "cloud API workload CODEX_HOME contract"); expectNoProxyIncludes(ctx, env.get("NO_PROXY")?.value, contract.noProxyRequired, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.NO_PROXY"); expectNoProxyIncludes(ctx, env.get("no_proxy")?.value, contract.noProxyRequired, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.no_proxy"); + validateCodexApiForwarderArtifacts(ctx, podContainers, contract); validateCodexHomeMountArtifacts(ctx, container?.volumeMounts ?? [], volumes, contract); expect( ctx, @@ -622,6 +638,26 @@ function expectNoProxyIncludes(ctx, value, required, path, label = "NO_PROXY con } } +function validateCodexApiForwarderArtifacts(ctx, containers, contract) { + const sidecar = (containers ?? []).find((entry) => entry.name === contract.forwarder.containerName); + expect(ctx, Boolean(sidecar), "codex_api_forwarder_missing", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.containers.hwlab-codex-api-forwarder", "cloud API pod includes codex-api loopback forwarder sidecar"); + if (!sidecar) return; + expect(ctx, JSON.stringify(sidecar.command) === JSON.stringify(contract.forwarder.command), "codex_api_forwarder_command_mismatch", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.containers.hwlab-codex-api-forwarder.command", "codex-api forwarder command"); + expect( + ctx, + (sidecar.ports ?? []).some((entry) => entry.name === contract.forwarder.portName && entry.containerPort === contract.forwarder.port), + "codex_api_forwarder_port_missing", + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.containers.hwlab-codex-api-forwarder.ports", + "codex-api forwarder exposes only a Pod-local container port" + ); + const env = new Map((sidecar.env ?? []).map((entry) => [entry.name, entry.value])); + expectEqual(ctx, env.get(contract.forwarder.listenHostEnv), "127.0.0.1", `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.containers.hwlab-codex-api-forwarder.env.${contract.forwarder.listenHostEnv}`, "codex-api forwarder loopback listen host env"); + expectEqual(ctx, env.get(contract.forwarder.upstreamEnv), contract.forwarder.upstreamBaseUrl, `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.containers.hwlab-codex-api-forwarder.env.${contract.forwarder.upstreamEnv}`, "codex-api forwarder upstream env"); + expectEqual(ctx, env.get(contract.forwarder.portEnv), String(contract.forwarder.port), `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.containers.hwlab-codex-api-forwarder.env.${contract.forwarder.portEnv}`, "codex-api forwarder port env"); + expectNoProxyIncludes(ctx, env.get("NO_PROXY"), contract.noProxyRequired, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.containers.hwlab-codex-api-forwarder.env.NO_PROXY", "codex-api forwarder NO_PROXY contract"); + expectNoProxyIncludes(ctx, env.get("no_proxy"), contract.noProxyRequired, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.containers.hwlab-codex-api-forwarder.env.no_proxy", "codex-api forwarder no_proxy contract"); +} + function validateCodexHomeMountArtifacts(ctx, volumeMounts, volumes, contract) { expect( ctx, diff --git a/scripts/src/deploy-desired-state-plan.mjs b/scripts/src/deploy-desired-state-plan.mjs index 6f5f3b04..e3b26bf3 100644 --- a/scripts/src/deploy-desired-state-plan.mjs +++ b/scripts/src/deploy-desired-state-plan.mjs @@ -201,7 +201,7 @@ function recordObservation(ctx, observation) { } function expectedEnvValue(name, service, desiredCommitId) { - if (name === "HWLAB_COMMIT_ID" || name === "HWLAB_SKILLS_COMMIT_ID") return desiredCommitId; + if (name === "HWLAB_COMMIT_ID" || name === "HWLAB_SKILLS_COMMIT_ID") return service?.commitId ?? service?.imageTag ?? desiredCommitId; if (name === "HWLAB_IMAGE") return service?.image ?? null; if (name === "HWLAB_IMAGE_TAG") return service?.imageTag ?? null; if (name === "HWLAB_IMAGE_DIGEST") return service?.digest ?? "not_published"; @@ -306,7 +306,8 @@ function inspectCodeAgentProviderDesiredState(ctx, deployService, workloadRecord missingSecretRefs: inspection.missingSecretRefs, missingEgressContract: inspection.missingEgressContract, missingCodexHomeContract: inspection.missingCodexHomeContract, - missingNoProxyContract: inspection.missingNoProxyContract + missingNoProxyContract: inspection.missingNoProxyContract, + missingForwarderContract: inspection.missingForwarderContract }, secretMaterialRead: false, valuesRedacted: true @@ -326,8 +327,8 @@ function inspectCodeAgentProviderDesiredState(ctx, deployService, workloadRecord serviceId: "hwlab-cloud-api", path: `${workloadsPath}.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL`, field: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", - kind: "egress-proxy", - value: inspection.egress.workloadMatchesDevProxy ? "dev-egress-proxy" : "missing_or_mismatched", + kind: "codex-api-forwarder", + value: inspection.egress.workloadMatchesDevProxy ? "pod-local-codex-api-forwarder" : "missing_or_mismatched", expected: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.target }); return inspection; @@ -443,7 +444,8 @@ function indexWorkloads(workloads) { env: { ...envListToObject(container?.env), __volumeMounts: Array.isArray(container?.volumeMounts) ? container.volumeMounts : [], - __volumes: Array.isArray(item?.spec?.template?.spec?.volumes) ? item.spec.template.spec.volumes : [] + __volumes: Array.isArray(item?.spec?.template?.spec?.volumes) ? item.spec.template.spec.volumes : [], + __podContainers: Array.isArray(item?.spec?.template?.spec?.containers) ? item.spec.template.spec.containers : [] }, path: `${workloadsPath}.${item?.kind ?? "Workload"}.${item?.metadata?.name ?? serviceId}.containers[${containerIndex}]` }; @@ -796,10 +798,12 @@ export async function buildDesiredStatePlan(options = {}) { const deployImage = parseImageRef(deployService?.image); const catalogImage = parseImageRef(catalogService?.image); - const serviceImageTag = catalogService?.imageTag ?? deployImage.tag; + const serviceImageTag = catalogService?.imageTag ?? deployImage.tag ?? catalogImage.tag ?? desiredImageTag; + const serviceCommitId = catalogService?.commitId ?? serviceImageTag; const expectedService = { image: deployService?.image ?? catalogService?.image ?? null, - imageTag: desiredImageTag, + imageTag: serviceImageTag, + commitId: serviceCommitId, digest: catalogService?.digest ?? "not_published" }; @@ -816,8 +820,8 @@ export async function buildDesiredStatePlan(options = {}) { }); if (!deployImage.valid) { addMismatch(ctx, "invalid_image", `${deployPathBase}.image`, deployImage.reason, "tagged image ref", deployService.image, { serviceId }); - } else if (deployImage.tag !== desiredImageTag) { - addMismatch(ctx, "image_tag_mismatch", `${deployPathBase}.image`, `${serviceId} deploy image tag must match deploy commit tag`, desiredImageTag, deployImage.tag, { serviceId }); + } else if (deployImage.tag !== serviceImageTag) { + addMismatch(ctx, "image_tag_mismatch", `${deployPathBase}.image`, `${serviceId} deploy image tag must match service artifact tag`, serviceImageTag, deployImage.tag, { serviceId }); } service.deploy = { image: deployService.image, @@ -843,7 +847,7 @@ export async function buildDesiredStatePlan(options = {}) { field: "commitId", kind: "commit", value: catalogService.commitId, - expected: desiredCommitId + expected: serviceCommitId }); recordObservation(ctx, { source: "catalog", @@ -861,16 +865,18 @@ export async function buildDesiredStatePlan(options = {}) { field: "imageTag", kind: "tag", value: catalogService.imageTag, - expected: desiredImageTag + expected: serviceImageTag }); - if (!commitEquivalent(catalogService.commitId, desiredCommitId)) { - addMismatch(ctx, "commit_mismatch", `${catalogPathBase}.commitId`, `${serviceId} catalog service commitId must match deploy commitId`, desiredCommitId, catalogService.commitId, { serviceId }); + if (!commitPattern.test(String(catalogService.commitId ?? ""))) { + addMismatch(ctx, "invalid_commit", `${catalogPathBase}.commitId`, `${serviceId} catalog service commitId must be a short or full lowercase Git SHA`, "7-40 lowercase hex", catalogService.commitId, { serviceId }); + } else if (!commitEquivalent(catalogService.commitId, serviceImageTag)) { + addMismatch(ctx, "commit_mismatch", `${catalogPathBase}.commitId`, `${serviceId} catalog service commitId must match service artifact tag`, serviceImageTag, catalogService.commitId, { serviceId }); } if (!catalogImage.valid) { addMismatch(ctx, "invalid_image", `${catalogPathBase}.image`, catalogImage.reason, "tagged image ref", catalogService.image, { serviceId }); } - if (catalogService.imageTag !== desiredImageTag) { - addMismatch(ctx, "image_tag_mismatch", `${catalogPathBase}.imageTag`, `${serviceId} catalog imageTag must match deploy commit tag`, desiredImageTag, catalogService.imageTag, { serviceId }); + if (catalogService.imageTag !== serviceImageTag) { + addMismatch(ctx, "image_tag_mismatch", `${catalogPathBase}.imageTag`, `${serviceId} catalog imageTag must match service artifact tag`, serviceImageTag, catalogService.imageTag, { serviceId }); } if (catalogImage.valid && catalogImage.tag !== catalogService.imageTag) { addMismatch(ctx, "image_tag_mismatch", `${catalogPathBase}.image`, `${serviceId} catalog image tag must match catalog imageTag`, catalogService.imageTag, catalogImage.tag, { serviceId }); @@ -901,8 +907,8 @@ export async function buildDesiredStatePlan(options = {}) { }); if (!workloadImage.valid) { addMismatch(ctx, "invalid_image", `${workload.path}.image`, workloadImage.reason, "tagged image ref", workload.image, { serviceId }); - } else if (workloadImage.tag !== desiredImageTag) { - addMismatch(ctx, "image_tag_mismatch", `${workload.path}.image`, `${serviceId} workload image tag must match deploy commit tag`, desiredImageTag, workloadImage.tag, { serviceId }); + } else if (workloadImage.tag !== serviceImageTag) { + addMismatch(ctx, "image_tag_mismatch", `${workload.path}.image`, `${serviceId} workload image tag must match service artifact tag`, serviceImageTag, workloadImage.tag, { serviceId }); } if (deployService?.image && workload.image !== deployService.image) { addMismatch(ctx, "image_mismatch", `${workload.path}.image`, `${serviceId} workload image must match deploy image`, deployService.image, workload.image, { serviceId }); diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index 536e9047..e17456ff 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -41,6 +41,7 @@ export const DEFAULT_GITOPS_ONLY_PATHS = Object.freeze([ const serviceSpecificPaths = Object.freeze({ "hwlab-cloud-api": [ "cmd/hwlab-cloud-api/", + "cmd/hwlab-codex-api-responses-forwarder/", "cmd/hwlab-deepseek-responses-bridge/", "internal/cloud/", "internal/db/", diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index 8586e162..fa13ac82 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -77,6 +77,16 @@ function assertUnique(name, values) { assert.equal(new Set(values).size, values.length, `${name} must be unique`); } +function imageTagFromReference(image) { + if (typeof image !== "string" || image.length === 0) return null; + const digestIndex = image.indexOf("@"); + const withoutDigest = digestIndex >= 0 ? image.slice(0, digestIndex) : image; + const slashIndex = withoutDigest.lastIndexOf("/"); + const colonIndex = withoutDigest.lastIndexOf(":"); + if (colonIndex <= slashIndex) return null; + return withoutDigest.slice(colonIndex + 1) || null; +} + function assertCommonSchema(commonSchema) { const schemaServiceIds = commonSchema.$defs.serviceId.enum; assert.deepEqual(schemaServiceIds, SERVICE_IDS, "common service ids must match runtime constants"); @@ -165,6 +175,7 @@ const cloudApiContainer = cloudApiWorkload?.spec?.template?.spec?.containers?.fi const cloudApiWorkloadEnv = getWorkloadEnv(k8sWorkloads, "hwlab-cloud-api"); cloudApiWorkloadEnv.__volumeMounts = cloudApiContainer?.volumeMounts ?? []; cloudApiWorkloadEnv.__volumes = cloudApiWorkload?.spec?.template?.spec?.volumes ?? []; +cloudApiWorkloadEnv.__podContainers = cloudApiWorkload?.spec?.template?.spec?.containers ?? []; const patchPanelWorkloadEnv = getWorkloadEnv(k8sWorkloads, "hwlab-patch-panel"); assert.equal(deployManifest.health.path, "/health/live", "deploy health source path"); assert.equal(deployManifest.publicEndpoints.frontend.url, "http://74.48.78.17:16666", "deploy frontend endpoint"); @@ -176,9 +187,10 @@ assert.ok( "deploy edge proxy k3s mapping" ); assert.equal(cloudApi.healthPath, "/health/live", "cloud-api health path"); -assert.equal(cloudApi.env.HWLAB_COMMIT_ID, deployManifest.commitId, "cloud-api health commit evidence"); +const cloudApiImageTag = imageTagFromReference(cloudApi.image) ?? deployManifest.commitId.slice(0, 7); +assert.equal(cloudApi.env.HWLAB_COMMIT_ID, cloudApiImageTag, "cloud-api health commit evidence"); assert.equal(cloudApi.env.HWLAB_IMAGE, cloudApi.image, "cloud-api health image evidence"); -assert.equal(cloudApi.env.HWLAB_IMAGE_TAG, deployManifest.commitId.slice(0, 7), "cloud-api health image tag evidence"); +assert.equal(cloudApi.env.HWLAB_IMAGE_TAG, cloudApiImageTag, "cloud-api health image tag evidence"); assert.equal(cloudApi.env.HWLAB_CLOUD_DB_URL, "secretRef:hwlab-cloud-api-dev-db/database-url", "cloud-api DB URL must be a Secret reference placeholder"); assert.equal( cloudApi.env.HWLAB_CLOUD_DB_SSL_MODE, @@ -206,10 +218,17 @@ assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_PORT, undefined, "cloud-api work assertCloudApiDbOptionalAlias(k8sServices); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_PROVIDER, DEV_CODE_AGENT_PROVIDER_CONTRACT.codeAgentProvider, "cloud-api Code Agent provider"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, DEV_CODE_AGENT_PROVIDER_CONTRACT.model, "cloud-api Code Agent model"); +assert.equal(cloudApi.env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE, DEV_CODE_AGENT_PROVIDER_CONTRACT.profiles.defaultProfile, "cloud-api default provider profile"); +assert.equal(cloudApi.env[DEV_CODE_AGENT_PROVIDER_CONTRACT.profiles.deepSeekModelEnv], "deepseek-chat", "cloud-api DeepSeek profile model"); +assert.equal(cloudApi.env[DEV_CODE_AGENT_PROVIDER_CONTRACT.profiles.deepSeekBaseUrlEnv], DEV_CODE_AGENT_PROVIDER_CONTRACT.profiles.deepSeekBaseUrl, "cloud-api DeepSeek profile base URL"); +assert.equal(cloudApi.env[DEV_CODE_AGENT_PROVIDER_CONTRACT.profiles.codexApiModelEnv], DEV_CODE_AGENT_PROVIDER_CONTRACT.model, "cloud-api codex-api profile model"); +assert.equal(cloudApi.env[DEV_CODE_AGENT_PROVIDER_CONTRACT.profiles.codexApiBaseUrlEnv], DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.defaultBaseUrl, "cloud-api codex-api profile base URL"); +assert.equal(cloudApi.env[DEV_CODE_AGENT_PROVIDER_CONTRACT.forwarder.upstreamEnv], DEV_CODE_AGENT_PROVIDER_CONTRACT.forwarder.upstreamBaseUrl, "cloud-api codex-api upstream base URL"); +assert.equal(cloudApi.env[DEV_CODE_AGENT_PROVIDER_CONTRACT.forwarder.portEnv], String(DEV_CODE_AGENT_PROVIDER_CONTRACT.forwarder.port), "cloud-api codex-api forwarder port"); assert.equal( cloudApi.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL, DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.defaultBaseUrl, - "cloud-api Code Agent OpenAI base URL must use DEV egress/proxy" + "cloud-api Code Agent OpenAI base URL must use pod-local Codex API forwarder" ); assert.equal( cloudApi.env[HWLAB_M3_IO_API_BASE_URL_ENV], @@ -278,10 +297,17 @@ function assertCodeAgentProviderWorkloadContract(env) { const apiKey = env[secretRef.env]?.valueFrom?.secretKeyRef; assert.equal(env.HWLAB_CODE_AGENT_PROVIDER?.value, contract.codeAgentProvider, "cloud-api workload Code Agent provider"); assert.equal(env.HWLAB_CODE_AGENT_MODEL?.value, contract.model, "cloud-api workload Code Agent model"); + assert.equal(env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE?.value, contract.profiles.defaultProfile, "cloud-api workload default provider profile"); + assert.equal(env[contract.profiles.deepSeekModelEnv]?.value, "deepseek-chat", "cloud-api workload DeepSeek profile model"); + assert.equal(env[contract.profiles.deepSeekBaseUrlEnv]?.value, contract.profiles.deepSeekBaseUrl, "cloud-api workload DeepSeek profile base URL"); + assert.equal(env[contract.profiles.codexApiModelEnv]?.value, contract.model, "cloud-api workload codex-api profile model"); + assert.equal(env[contract.profiles.codexApiBaseUrlEnv]?.value, contract.egress.defaultBaseUrl, "cloud-api workload codex-api profile base URL must use pod-local forwarder"); + assert.equal(env[contract.forwarder.upstreamEnv]?.value, contract.forwarder.upstreamBaseUrl, "cloud-api workload codex-api upstream must direct-connect to hyueapi"); + assert.equal(env[contract.forwarder.portEnv]?.value, String(contract.forwarder.port), "cloud-api workload codex-api forwarder port"); assert.equal( env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value, contract.egress.defaultBaseUrl, - "cloud-api workload Code Agent OpenAI base URL must use DEV egress/proxy" + "cloud-api workload Code Agent OpenAI base URL must use pod-local Codex API forwarder" ); assert.equal( env[HWLAB_M3_IO_API_BASE_URL_ENV]?.value, @@ -298,6 +324,7 @@ function assertCodeAgentProviderWorkloadContract(env) { assertNoProxyIncludes(env.NO_PROXY?.value, contract.noProxyRequired, "cloud-api workload NO_PROXY contract"); assertNoProxyIncludes(env.no_proxy?.value, contract.noProxyRequired, "cloud-api workload no_proxy contract"); assertCodexHomeMountContract(env, contract); + assertCodexApiForwarderContract(env, contract); assert.notEqual( env.HWLAB_CODE_AGENT_OPENAI_BASE_URL?.value, contract.egress.forbiddenDirectBaseUrl, @@ -317,6 +344,22 @@ function assertNoProxyIncludes(value, required, label) { } } +function assertCodexApiForwarderContract(env, contract) { + const sidecar = (env.__podContainers ?? []).find((entry) => entry.name === contract.forwarder.containerName); + assert.ok(sidecar, "cloud-api pod includes codex-api loopback forwarder sidecar"); + assert.deepEqual(sidecar.command, contract.forwarder.command, "codex-api forwarder command"); + assert.ok( + (sidecar.ports ?? []).some((entry) => entry.name === contract.forwarder.portName && entry.containerPort === contract.forwarder.port), + "codex-api forwarder exposes only Pod-local container port" + ); + const sidecarEnv = Object.fromEntries((sidecar.env ?? []).map((entry) => [entry.name, entry.value])); + assert.equal(sidecarEnv[contract.forwarder.listenHostEnv], "127.0.0.1", "codex-api forwarder loopback listen host env"); + assert.equal(sidecarEnv[contract.forwarder.upstreamEnv], contract.forwarder.upstreamBaseUrl, "codex-api forwarder upstream env"); + assert.equal(sidecarEnv[contract.forwarder.portEnv], String(contract.forwarder.port), "codex-api forwarder port env"); + assertNoProxyIncludes(sidecarEnv.NO_PROXY, contract.noProxyRequired, "codex-api forwarder NO_PROXY contract"); + assertNoProxyIncludes(sidecarEnv.no_proxy, contract.noProxyRequired, "codex-api forwarder no_proxy contract"); +} + function assertCodexHomeMountContract(env, contract) { const volumeMounts = env.__volumeMounts ?? []; const volumes = env.__volumes ?? [];