diff --git a/actions/setup/js/awf_reflect.cjs b/actions/setup/js/awf_reflect.cjs index 9e57aca4be6..9cfa21fcdc6 100644 --- a/actions/setup/js/awf_reflect.cjs +++ b/actions/setup/js/awf_reflect.cjs @@ -71,6 +71,48 @@ const REFLECT_PROVIDER_ALIASES = { anthropic: new Set(["anthropic"]), }; +const DEFAULT_API_PROXY_HOST_BRIDGE = "host.docker.internal"; + +/** + * Detect the sbx HOSTALIASES mapping that makes `api-proxy` resolve to localhost. + * In that topology AWF creates a localhost bridge only for the management + * /reflect port, so provider traffic for ports such as 10002 must use the + * host-side Docker gateway name instead. + * + * @param {NodeJS.ProcessEnv} [env] + * @param {(path: string, encoding: BufferEncoding) => string} [readFileSync] + * @returns {boolean} + */ +function hasAPIProxyLocalhostAlias(env = process.env, readFileSync = fs.readFileSync) { + const hostAliasesPath = env.HOSTALIASES; + if (!hostAliasesPath) return false; + try { + const aliases = readFileSync(hostAliasesPath, "utf8"); + return aliases.split(/\r?\n/).some(line => { + const trimmed = line.replace(/#.*/, "").trim(); + if (!trimmed) return false; + const parts = trimmed.split(/\s+/); + return parts[0] === "api-proxy" && (parts[1] === "localhost" || parts[1] === "127.0.0.1"); + }); + } catch { + return false; + } +} + +/** + * Rewrite api-proxy URLs for sbx HOSTALIASES bridge mode. + * + * @param {string} url + * @param {NodeJS.ProcessEnv} [env] + * @param {(path: string, encoding: BufferEncoding) => string} [readFileSync] + * @returns {string} + */ +function rewriteAPIProxyURLForHostBridge(url, env = process.env, readFileSync = fs.readFileSync) { + if (!hasAPIProxyLocalhostAlias(env, readFileSync)) return url; + const bridgeHost = env.GH_AW_API_PROXY_HOST_BRIDGE || DEFAULT_API_PROXY_HOST_BRIDGE; + return url.replace(/^(https?:\/\/)api-proxy(?=[:/]|$)/i, `$1${bridgeHost}`); +} + // Default logger used by fetchAWFReflect when no logger is provided via options. // All lines are prefixed with "[awf-reflect]" for easy grepping in combined logs. // prettier-ignore @@ -136,6 +178,7 @@ function extractModelIds(json) { * @returns {Promise} */ async function fetchModelsFromUrl(modelsUrl, timeoutMs, logger) { + const requestUrl = rewriteAPIProxyURLForHostBridge(modelsUrl); let isInitialProbeDelayed = false; try { const modelsHost = new URL(modelsUrl).hostname.toLowerCase(); @@ -182,7 +225,7 @@ async function fetchModelsFromUrl(modelsUrl, timeoutMs, logger) { ac.abort(); }, timeoutMs); try { - const res = await fetch(modelsUrl, { signal: ac.signal }); + const res = await fetch(requestUrl, { signal: ac.signal }); if (!res.ok) { if (res.status === 503) { const err = Object.assign(new Error(`models fetch returned 503 for ${modelsUrl}`), { status: 503 }); @@ -534,13 +577,13 @@ function inferWireApiForModel(providerType, modelName, catalogEntryOrModelsJson) function endpointBaseUrl(endpoint) { if (typeof endpoint.models_url === "string" && endpoint.models_url) { try { - return new URL(endpoint.models_url).origin; + return rewriteAPIProxyURLForHostBridge(new URL(endpoint.models_url).origin); } catch { // fall through to port-based construction } } if (endpoint.port != null) { - return `http://api-proxy:${String(endpoint.port)}`; + return rewriteAPIProxyURLForHostBridge(`http://api-proxy:${String(endpoint.port)}`); } return ""; } @@ -793,17 +836,20 @@ if (typeof module !== "undefined" && module.exports) { AWF_MODELS_URL_MAX_ATTEMPTS, AWF_MODELS_URL_RETRY_BASE_MS, AWF_MODELS_URL_RETRY_MAX_MS, + DEFAULT_API_PROXY_HOST_BRIDGE, GEMINI_MODEL_NAME_PREFIX, enrichReflectModels, extractModelIds, fetchAWFReflect, fetchModelsFromUrl, getCatalogModelEntry, + hasAPIProxyLocalhostAlias, inferProviderTypeForModel, inferWireApiForModel, normalizeReflectProviderName, resolveOpenAICompatibleEndpointFromReflect, resolveProviderEndpointFromReflect, resolveMultiProviderFromReflect, + rewriteAPIProxyURLForHostBridge, }; } diff --git a/actions/setup/js/awf_reflect.test.cjs b/actions/setup/js/awf_reflect.test.cjs index 657dceb70e4..aa9315e02be 100644 --- a/actions/setup/js/awf_reflect.test.cjs +++ b/actions/setup/js/awf_reflect.test.cjs @@ -13,17 +13,20 @@ const { AWF_MODELS_URL_MAX_ATTEMPTS, AWF_MODELS_URL_RETRY_BASE_MS, AWF_MODELS_URL_RETRY_MAX_MS, + DEFAULT_API_PROXY_HOST_BRIDGE, GEMINI_MODEL_NAME_PREFIX, enrichReflectModels, extractModelIds, fetchAWFReflect, fetchModelsFromUrl, getCatalogModelEntry, + hasAPIProxyLocalhostAlias, inferProviderTypeForModel, inferWireApiForModel, resolveOpenAICompatibleEndpointFromReflect, resolveProviderEndpointFromReflect, resolveMultiProviderFromReflect, + rewriteAPIProxyURLForHostBridge, } = require("./awf_reflect.cjs"); describe("awf_reflect.cjs", () => { @@ -36,10 +39,36 @@ describe("awf_reflect.cjs", () => { expect(AWF_MODELS_URL_MAX_ATTEMPTS).toBe(5); expect(AWF_MODELS_URL_RETRY_BASE_MS).toBe(250); expect(AWF_MODELS_URL_RETRY_MAX_MS).toBe(2000); + expect(DEFAULT_API_PROXY_HOST_BRIDGE).toBe("host.docker.internal"); expect(GEMINI_MODEL_NAME_PREFIX).toBe("models/"); }); }); + describe("rewriteAPIProxyURLForHostBridge", () => { + it("does not rewrite api-proxy URLs without a localhost HOSTALIASES mapping", () => { + const env = { HOSTALIASES: "/tmp/aliases" }; + const readFileSync = () => "other-host localhost\n"; + + expect(hasAPIProxyLocalhostAlias(env, readFileSync)).toBe(false); + expect(rewriteAPIProxyURLForHostBridge("http://api-proxy:10002/models", env, readFileSync)).toBe("http://api-proxy:10002/models"); + }); + + it("rewrites api-proxy URLs when HOSTALIASES maps api-proxy to localhost", () => { + const env = { HOSTALIASES: "/tmp/aliases" }; + const readFileSync = () => "# generated by awf\napi-proxy localhost\n"; + + expect(hasAPIProxyLocalhostAlias(env, readFileSync)).toBe(true); + expect(rewriteAPIProxyURLForHostBridge("http://api-proxy:10002/models", env, readFileSync)).toBe("http://host.docker.internal:10002/models"); + }); + + it("uses an override bridge host when provided", () => { + const env = { HOSTALIASES: "/tmp/aliases", GH_AW_API_PROXY_HOST_BRIDGE: "172.30.0.1" }; + const readFileSync = () => "api-proxy 127.0.0.1\n"; + + expect(rewriteAPIProxyURLForHostBridge("http://api-proxy:10002", env, readFileSync)).toBe("http://172.30.0.1:10002"); + }); + }); + describe("extractModelIds", () => { it("returns null for null input", () => { expect(extractModelIds(null)).toBeNull(); @@ -573,6 +602,26 @@ describe("awf_reflect.cjs", () => { expect(result.model).toBe("gpt-5.4"); }); + it("rewrites provider baseUrl to the host bridge in sbx HOSTALIASES mode", () => { + const originalHostAliases = process.env.HOSTALIASES; + const aliasesPath = path.join(os.tmpdir(), `awf-hostaliases-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.writeFileSync(aliasesPath, "api-proxy localhost\n", "utf8"); + process.env.HOSTALIASES = aliasesPath; + try { + const result = resolveMultiProviderFromReflect({ + reflectData: { endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.4"] }] }, + }); + expect(result.providers[0].baseUrl).toBe("http://host.docker.internal:10002"); + } finally { + if (originalHostAliases === undefined) { + delete process.env.HOSTALIASES; + } else { + process.env.HOSTALIASES = originalHostAliases; + } + fs.rmSync(aliasesPath, { force: true }); + } + }); + it("returns null when no configured endpoints exist", () => { const result = resolveMultiProviderFromReflect({ reflectData: {